/* eslint-disable @typescript-eslint/no-explicit-any */
import { Properties } from 'csstype';
import { KnnoDocument } from './dom';
import { encode } from 'html-entities';

export type CSSKey = keyof Properties;
export type CSSProperties = Properties;
export type EventKey = keyof HTMLElementEventMap;
export type EventHandler<T extends EventKey, O extends Nodes> = (this: O, event: HTMLElementEventMap[T]) => void;

export const ML_NS = 'http://www.w3.org/1998/Math/MathML';
export const SVG_NS = 'http://www.w3.org/2000/svg';

function propApply<V>(
	box: Nodes,
	getValue: (elem: Element, name: string) => V | null,
	setValue: (elem: Element, name: string, value: V) => void,
	name: string | Partial<Record<string, V>>,
	value?: V
): V | null | Nodes {
	let partial: Partial<Record<string, V>>;
	if (typeof name === 'string') {
		if (typeof value !== 'undefined') {
			partial = {};
			partial[name] = value;
		} else {
			const elem = box.elem();
			if (elem) {
				return getValue(elem, name);
			} else return null;
		}
	} else {
		partial = name;
	}
	box.elems().forEach((e) => {
		Object.entries(partial).forEach(([key, value]) => {
			setValue(e, key, value as any);
		});
	});
	return box;
}

const EL = Symbol();

function isElement(node: Element | Text): node is Element {
	return node.nodeType === 1;
}

export class Nodes extends Array<Element | Text> {
	private [EL]?: Map<EventListener, EventListener>;
	constructor(elements: Iterable<Element | Text>) {
		super();
		if (typeof elements === 'number') {
			return;
		} else {
			for (const elem of elements) {
				this.push(elem);
			}
		}
	}
	elems(): Element[] {
		return [...this].filter((e) => e.nodeType === 1 /*Node.ELEMENT_NODE*/) as Element[];
	}

	elem(): HTMLElement | undefined {
		return this.elems()[0] as HTMLElement | undefined;
	}

	ref(fn: (nodes: this) => void): this {
		fn(this);
		return this;
	}
	query(selector: string): Nodes {
		return new Nodes(
			this.elems()
				.map((e) => [...e.querySelectorAll(selector)])
				.flat(1)
		);
	}
	firstChild(): Nodes {
		return new Nodes(
			this.elems()
				.map((item) => item.firstElementChild ?? [])
				.flat(1)
		);
	}
	lastChild(): Nodes {
		return new Nodes(
			this.elems()
				.map((item) => item.lastElementChild ?? [])
				.flat(1)
		);
	}
	child(n: number): Nodes {
		return new Nodes(
			this.elems()
				.map((item) => [...item.children][n] ?? [])
				.flat(1)
		);
	}
	children(): Nodes {
		return new Nodes(
			this.elems()
				.map((n) => [...n.children])
				.flat(1)
		);
	}
	parent(): Nodes {
		return new Nodes(
			this.elems()
				.map((n) => n.parentElement)
				.filter((n): n is HTMLElement => !!n)
		);
	}
	name(): string | null;
	name(value: string | null): this;
	name(value?: string | null) {
		if (typeof value === 'string' || value === null) {
			return this.attr('name', value);
		} else {
			return this.attr('name');
		}
	}

	class(): string[];
	class(name: string | string[]): this;
	class(name?: string | string[]): string[] | this {
		if (typeof name === 'undefined') {
			return [...(this.elem()?.classList ?? [])];
		}
		const arr = [name]
			.flat(1)
			.join(' ')
			.split(/\s+/)
			.filter((s) => !!s);
		this.elems().forEach((e) => {
			e.removeAttribute('class');
			if (arr.length > 0) {
				e.classList.add(...arr);
			}
		});
		return this;
	}
	addClass(...name: string[]) {
		if (name.length === 0) return this;
		const arr = name
			.join(' ')
			.split(/\s+/)
			.filter((s) => !!s);
		this.elems().forEach((e) => {
			e.classList.add(...arr);
		});
		return this;
	}
	removeClass(...name: string[]) {
		if (name.length === 0) return this;
		const arr = name
			.join(' ')
			.split(/\s+/)
			.filter((s) => !!s);
		this.elems().forEach((e) => {
			e.classList.remove(...arr);
			if (e.classList.length === 0) {
				e.removeAttribute('class');
			}
		});
		return this;
	}
	toggleClass(...name: string[]) {
		if (name.length === 0) return this;
		const arr = name
			.join(' ')
			.split(/\s+/)
			.filter((s) => !!s);
		this.elems().forEach((e) => {
			arr.forEach((c) => e.classList.toggle(c));
			if (e.classList.length === 0) {
				e.removeAttribute('class');
			}
		});
		return this;
	}

	css(): CSSProperties | null;
	css<T extends CSSKey>(name: T, value: Exclude<CSSProperties[T], undefined>): this;
	css(name: string, value: string | number): this;
	css(name: CSSProperties): this;
	css(name: string): this;
	css<T extends CSSKey>(name: T): string;
	css<T extends CSSKey>(
		name?: T | CSSProperties,
		value?: CSSProperties[T]
	): CSSProperties | CSSProperties[T] | this | null {
		if (name === undefined) {
			const elem = this.elem();
			if (elem) {
				return getComputedStyle(elem) as CSSProperties;
			} else {
				return null;
			}
		}
		return propApply(
			this,
			(elem, k) => getComputedStyle(elem)[k as any],
			(elem, k, v) => {
				if (k.startsWith('--')) {
					(elem as HTMLElement).style.setProperty(k, v === undefined ? '' : v + '');
				} else {
					(elem as HTMLElement).style[k as any] = v === undefined ? '' : v + '';
				}
			},
			name as string | Partial<Record<string, unknown>>,
			value
		) as this | CSSProperties[T] | CSSProperties;
	}
	on<K extends keyof HTMLElementEventMap>(
		event: K,
		listener: EventHandler<K, this>,
		options?: boolean | AddEventListenerOptions
	): this;
	on(event: string, listener: EventListener, options?: boolean | AddEventListenerOptions): this;
	on(event: string, listener: EventListener, options?: boolean | AddEventListenerOptions): this {
		let ev: EventListener & { refCount?: number };
		if (typeof listener === 'function') {
			if (this[EL] === undefined) this[EL] = new Map();
			const fn = this[EL].get(listener);
			if (fn) {
				ev = fn;
				ev.refCount!++;
			} else {
				const evtFn = listener.bind(this) as (evt: Event, target: typeof this) => void;
				ev = (evt) => {
					evtFn(evt, this);
				};
				ev.refCount = 1;
				this[EL].set(listener, ev);
			}
		} else ev = listener;
		this.elems().forEach((e) => e.addEventListener(event, ev, options));
		return this;
	}
	off<K extends keyof HTMLElementEventMap>(
		event: K,
		listener: EventHandler<K, this>,
		options?: boolean | AddEventListenerOptions
	): this;
	off(event: string, listener: EventListener, options?: boolean | AddEventListenerOptions): this;
	off(event: string, listener: EventListener, options?: boolean | AddEventListenerOptions): this {
		let ev: EventListener & { refCount?: number };
		if (typeof listener === 'function') {
			if (this[EL]) {
				const fn = this[EL].get(listener);
				if (!fn) return this;
				ev = fn;
				ev.refCount!--;
				if (ev.refCount! <= 0) {
					this[EL].delete(listener);
				}
				if (this[EL].size === 0) {
					delete this[EL];
				}
			} else return this;
		} else ev = listener;
		this.elems().forEach((e) => e.removeEventListener(event, ev, options));
		return this;
	}
	emit(event: Event): this;
	emit<T = unknown>(event: string, detail?: T): this;
	emit<T = unknown>(event: Event | string, detail?: T): this {
		if (typeof event === 'string') {
			event = new CustomEvent(event, { detail });
		}
		this.forEach((e) => e.dispatchEvent(event as Event));
		return this;
	}
	html(): string;
	html(text: string): this;
	html(text?: string): string | this {
		if (typeof text === 'string') {
			this.elems().forEach((e) => (e.innerHTML = text));
			return this;
		} else {
			return this.elem()?.innerHTML ?? '';
		}
	}
	outerHTML(): string {
		return this.map((e) => (isElement(e) ? e.outerHTML : encode(e.textContent))).join('');
	}
	text(): string;
	text(text: string): this;
	text(text?: string): string | this {
		if (text !== undefined) {
			this.forEach((e) => (e.textContent = text + ''));
			return this;
		} else {
			return this[0]?.textContent ?? '';
		}
	}

	attr(name: string, value: string | null | boolean): this;
	attr(name: Record<string, string | null | boolean>): this;
	attr(name: string): string | null;
	attr(name: string | Record<string, string | null | boolean>, value?: string | null | boolean): string | null | this {
		return propApply(
			this,
			(elem, k) => elem.getAttribute(k),
			(elem, k, v) => {
				if (v === null) {
					elem.removeAttribute(k);
				} else {
					if (typeof v === 'boolean') {
						if (v) elem.setAttribute(k, k);
						else elem.removeAttribute(k);
					} else elem.setAttribute(k, v);
				}
			},
			name,
			value
		) as this;
	}

	prop<T>(name: string, value: T | null): this;
	prop<T>(name: Record<string, T | null>): this;
	prop<T>(name: string): T | null;
	prop<T>(name: string | Record<string, T | null>, value?: T | null): T | null | this {
		return propApply(
			this,
			(elem, k) => (elem as any)[k],
			(elem, k, v) => {
				(elem as any)[k] = v;
			},
			name,
			value
		) as this;
	}

	value(): unknown | undefined;
	value(value: unknown): this;
	value(value?: unknown): unknown | undefined | this {
		if (typeof value !== 'undefined') {
			this.forEach((el: any) => {
				el.value = value;
			});
			return this;
		} else {
			return (this[0] as any)?.value;
		}
	}

	appendTo(parent: Element | Element[], after?: Text | Element | (Text | Element)[]): this {
		const p = [parent].flat(1)[0];
		if (!p) return this;
		const ref = after ? ([after].flat(1)[0]?.nextSibling ?? null) : null;
		this.forEach((e) => p.insertBefore(e, ref));
		return this;
	}
	insertTo(parent: Element | Element[], before?: Text | Element | (Text | Element)[]): this {
		const p = [parent].flat(1)[0];
		if (!p) return this;
		const ref = before ? ([before].flat(1)[0] ?? p.firstChild) : p.firstChild;
		this.forEach((e) => p.insertBefore(e, ref));
		return this;
	}
	append(...elem: (Text | Element | (Element | Text)[])[]): this {
		if (this.length > 0) {
			Nodes.prototype.appendTo.apply(elem.flat(1), [this.elems()]);
		}
		return this;
	}
	appendAll(nodes: (Nodes | Text)[]): this {
		if (this.length > 0) {
			Nodes.prototype.appendTo.apply(nodes.flat(1), [this.elems()]);
		}
		return this;
	}
	insert(...elem: (Text | Element | (Element | Text)[])[]): this {
		if (this.length > 0) {
			Nodes.prototype.insertTo.apply(elem.flat(1), [this.elems()]);
		}
		return this;
	}
	insertAll(nodes: (Nodes | Text)[]): this {
		if (this.length > 0) {
			Nodes.prototype.insertTo.apply(nodes.flat(1), [this.elems()]);
		}
		return this;
	}
	remove(...elem: (Text | Element | (Element | Text)[])[]): this {
		if (this.length > 0) {
			this.forEach((p) => {
				elem.flat(1).forEach((el) => {
					p.removeChild(el);
				});
			});
		}
		return this;
	}
	detach(): this {
		this.forEach((e) => (e.parentElement ? e.parentElement.removeChild(e) : undefined));
		return this;
	}
	contain(...elem: (Text | Element | (Element | Text)[])[]): boolean {
		const targets = elem
			.flat(1)
			.map((el) => {
				const parents: Element[] = [];
				if (el.nodeType === 1 /*Node.ELEMENT_NODE*/) parents.push(el as Element);
				let p = el.parentElement;
				while (p) {
					parents.push(p);
					p = p.parentElement;
				}
				return parents;
			})
			.flat(1);
		return this.elems().findIndex((el) => targets.indexOf(el) >= 0) >= 0;
	}

	takeIf(cond?: (node: Nodes) => boolean): this | undefined {
		const exp = cond ?? ((node) => node.length > 0);
		return exp(this) ? this : undefined;
	}

	/**
	 * Get offset height of first element.
	 * @returns offset height value
	 */
	height(): number {
		return this.elem()?.offsetHeight ?? 0;
	}

	innerHeight(): number {
		return this.elem()?.clientHeight ?? 0;
	}

	/**
	 * Get offset width of first element.
	 * @returns offset width value
	 */
	width(): number {
		return this.elem()?.offsetWidth ?? 0;
	}

	innerWidth(): number {
		return this.elem()?.clientWidth ?? 0;
	}

	/**
	 * Get offset left of first element.
	 * @returns offset left value
	 */
	left(): number {
		return this.elem()?.offsetLeft ?? 0;
	}

	/**
	 * Get offset top of first element.
	 * @returns offset top value
	 */
	top(): number {
		return this.elem()?.offsetTop ?? 0;
	}

	scrollTop(): number;
	scrollTop(value: number): this;
	scrollTop(value?: number): number | this {
		return this.prop('scrollTop', value);
	}

	scrollLeft(): number;
	scrollLeft(value: number): this;
	scrollLeft(value?: number): number | this {
		return this.prop('scrollLeft', value);
	}

	scrollHeight(): number {
		return this.elem()?.scrollHeight ?? 0;
	}

	scrollWidth(): number {
		return this.elem()?.scrollWidth ?? 0;
	}

	focus(option?: FocusOptions): this {
		this.elem()?.focus(option);
		return this;
	}

	blur(): this {
		this.elem()?.blur();
		return this;
	}
}

interface Tags {
	a(...className: string[]): Nodes;
	abbr(...className: string[]): Nodes;
	address(...className: string[]): Nodes;
	area(...className: string[]): Nodes;
	article(...className: string[]): Nodes;
	aside(...className: string[]): Nodes;
	audio(...className: string[]): Nodes;
	b(...className: string[]): Nodes;
	base(...className: string[]): Nodes;
	bdi(...className: string[]): Nodes;
	bdo(...className: string[]): Nodes;
	blockquote(...className: string[]): Nodes;
	body(...className: string[]): Nodes;
	br(...className: string[]): Nodes;
	button(...className: string[]): Nodes;
	canvas(...className: string[]): Nodes;
	caption(...className: string[]): Nodes;
	cite(...className: string[]): Nodes;
	code(...className: string[]): Nodes;
	col(...className: string[]): Nodes;
	colgroup(...className: string[]): Nodes;
	data(...className: string[]): Nodes;
	datalist(...className: string[]): Nodes;
	dd(...className: string[]): Nodes;
	del(...className: string[]): Nodes;
	details(...className: string[]): Nodes;
	dfn(...className: string[]): Nodes;
	dialog(...className: string[]): Nodes;
	dir(...className: string[]): Nodes;
	div(...className: string[]): Nodes;
	dl(...className: string[]): Nodes;
	dt(...className: string[]): Nodes;
	em(...className: string[]): Nodes;
	embed(...className: string[]): Nodes;
	fieldset(...className: string[]): Nodes;
	figcaption(...className: string[]): Nodes;
	figure(...className: string[]): Nodes;
	font(...className: string[]): Nodes;
	footer(...className: string[]): Nodes;
	form(...className: string[]): Nodes;
	frame(...className: string[]): Nodes;
	frameset(...className: string[]): Nodes;
	h1(...className: string[]): Nodes;
	h2(...className: string[]): Nodes;
	h3(...className: string[]): Nodes;
	h4(...className: string[]): Nodes;
	h5(...className: string[]): Nodes;
	h6(...className: string[]): Nodes;
	head(...className: string[]): Nodes;
	header(...className: string[]): Nodes;
	hgroup(...className: string[]): Nodes;
	hr(...className: string[]): Nodes;
	i(...className: string[]): Nodes;
	iframe(...className: string[]): Nodes;
	img(...className: string[]): Nodes;
	input(...className: string[]): Nodes;
	ins(...className: string[]): Nodes;
	kbd(...className: string[]): Nodes;
	label(...className: string[]): Nodes;
	legend(...className: string[]): Nodes;
	li(...className: string[]): Nodes;
	link(...className: string[]): Nodes;
	main(...className: string[]): Nodes;
	map(...className: string[]): Nodes;
	mark(...className: string[]): Nodes;
	marquee(...className: string[]): Nodes;
	menu(...className: string[]): Nodes;
	meta(...className: string[]): Nodes;
	meter(...className: string[]): Nodes;
	nav(...className: string[]): Nodes;
	noscript(...className: string[]): Nodes;
	object(...className: string[]): Nodes;
	ol(...className: string[]): Nodes;
	optgroup(...className: string[]): Nodes;
	option(...className: string[]): Nodes;
	output(...className: string[]): Nodes;
	p(...className: string[]): Nodes;
	param(...className: string[]): Nodes;
	picture(...className: string[]): Nodes;
	pre(...className: string[]): Nodes;
	progress(...className: string[]): Nodes;
	q(...className: string[]): Nodes;
	rp(...className: string[]): Nodes;
	rt(...className: string[]): Nodes;
	ruby(...className: string[]): Nodes;
	s(...className: string[]): Nodes;
	samp(...className: string[]): Nodes;
	script(...className: string[]): Nodes;
	section(...className: string[]): Nodes;
	select(...className: string[]): Nodes;
	slot(...className: string[]): Nodes;
	small(...className: string[]): Nodes;
	source(...className: string[]): Nodes;
	span(...className: string[]): Nodes;
	strong(...className: string[]): Nodes;
	style(...className: string[]): Nodes;
	sub(...className: string[]): Nodes;
	summary(...className: string[]): Nodes;
	sup(...className: string[]): Nodes;
	table(...className: string[]): Nodes;
	tbody(...className: string[]): Nodes;
	td(...className: string[]): Nodes;
	template(...className: string[]): Nodes;
	textarea(...className: string[]): Nodes;
	tfoot(...className: string[]): Nodes;
	th(...className: string[]): Nodes;
	thead(...className: string[]): Nodes;
	time(...className: string[]): Nodes;
	title(...className: string[]): Nodes;
	tr(...className: string[]): Nodes;
	track(...className: string[]): Nodes;
	u(...className: string[]): Nodes;
	ul(...className: string[]): Nodes;
	var(...className: string[]): Nodes;
	video(...className: string[]): Nodes;
	wbr(...className: string[]): Nodes;
}

export interface IDOM extends Tags {
	query(selector: string): Nodes;
	wrap(...elements: (Element | Text)[]): Nodes;
	['new'](tagName: string, ...className: string[]): Nodes;
	['new'](tagName: keyof HTMLElementTagNameMap, ...className: string[]): Nodes;
	newNS(namespace: string, tagName: string, ...className: string[]): Nodes;
	newNS(namespace: typeof ML_NS, tagName: keyof MathMLElementTagNameMap, ...className: string[]): Nodes;
	newNS(namespace: typeof SVG_NS, tagName: keyof SVGElementTagNameMap, ...className: string[]): Nodes;
	ns(): string | null;
	ns(namespace: string | null): void;
	html(text: string): Nodes;
	text(text: string): Text;
	doc(): Document;
}

export function createDom(doc: Document): IDOM {
	let ns: string | null = null;
	// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
	const methods: Record<string | symbol, Function> = {
		query(selector: string): Nodes {
			return new Nodes(doc.querySelectorAll(selector));
		},
		wrap(...elements: (Element | Text)[]): Nodes {
			return new Nodes(elements);
		},
		new(tagName: string, ...className: string[]) {
			if (ns) {
				return new Nodes([doc.createElementNS(ns, tagName)]).class(className);
			} else {
				return new Nodes([doc.createElement(tagName)]).class(className);
			}
		},
		newNS(namespace: string, tagName: string, ...className: string[]) {
			return new Nodes([doc.createElementNS(namespace, tagName)]).class(className);
		},
		html(text: string): Nodes {
			const template = doc.createElement('div');
			template.innerHTML = text;
			return new Nodes(
				([...template.childNodes] as (Element | Text)[]).map((el) => {
					el.parentElement?.removeChild(el);
					return el;
				})
			);
		},
		ns(namespace?: string | null): void | string | null {
			if (namespace === undefined) return ns;
			ns = namespace;
		},
		text(text: string): Text {
			return doc.createTextNode(text);
		},
		doc(): Document {
			return doc;
		},
	};
	return new Proxy<IDOM>({} as IDOM, {
		get(_, propKey) {
			const method = methods[propKey];
			if (method) {
				return method;
			} else {
				return function (...className: string[]): Nodes {
					return methods.new(propKey, ...className);
				};
			}
		},
	});
}
const global = typeof globalThis === 'undefined' ? window : globalThis;
const defaultDom = createDom(global.document);
export let dom = createDom(global.document);
export function useDom(d: IDOM | null) {
	dom = d ?? defaultDom;
}

export function render(nodes: Nodes, selector?: string): void {
	const target = selector ? dom.query(selector) : dom.wrap(dom.doc().body);
	target.html('').append(nodes);
}

export function renderToDoc(document: Document, fn: (dom: IDOM) => Nodes, selector?: string): void {
	const originDom = dom;
	try {
		dom = createDom(document);
		const nodes = fn(dom);
		render(nodes, selector);
	} finally {
		dom = originDom;
	}
}

export function renderToString(fn: (dom: IDOM) => Nodes): string {
	const originDom = dom;
	try {
		dom = createDom(new KnnoDocument() as any as Document);
		const nodes = fn(dom);
		const result = nodes.outerHTML();
		return result;
	} finally {
		dom = originDom;
	}
}
export function renderH5(fn: (dom: IDOM) => Nodes): string {
	return '<!DOCTYP html>' + renderToString(fn);
}

export function renderNS(ns: string, fn: () => Nodes): Nodes {
	const previousNS = dom.ns();
	dom.ns(ns);
	try {
		return fn();
	} finally {
		dom.ns(previousNS);
	}
}

export function svg(fn: () => Nodes): Nodes {
	return renderNS(SVG_NS, fn);
}

export function ml(fn: () => Nodes): Nodes {
	return renderNS(ML_NS, fn);
}
